SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
11.4 KB · 200 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound, permanentRedirect } from 'next/navigation';4import { ExternalLink } from 'lucide-react';5import { PageHeader, Section, KV, Note } from '@/components/ui/section';6import { Badge } from '@/components/ui/badge';7import { EmptyState } from '@/components/ui/empty-state';8import { Freshness } from '@/components/ui/freshness';9import { Pager } from '@/components/ui/pager';10import { EvidenceTable } from '@/components/data/evidence-table';11import { GeneFrequencyTable } from '@/components/data/frequency-table';12import { getGeneBySymbol, variantsForGene, variantsForGeneCount, frequenciesForGene, VARIANT_PAGE_SIZE } from '@/lib/queries/genomics';13import { evidenceForGene, evidenceForGeneCount, evidenceCancersForGene, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence';14import { loadProvenance } from '@/lib/queries/provenance';15import { recentPublicationsFor, recentPublicationsForCount, PUBLICATION_PAGE_SIZE } from '@/lib/queries/publications';16import { PublicationList } from '@/components/data/publication-list';17import { GraphLink } from '@/components/graph/graph-link';18import { fmtInt, humanize } from '@/lib/format';19import { pageInfo } from '@/lib/pagination';20import { int, withParams, type SP } from '@/lib/search-params';2122export const revalidate = 3600;23/** Cancer chips above the evidence table: the most-cited contexts only (every row still names its cancer). */24const CANCER_CHIPS = 20;2526export async function generateMetadata({ params }: { params: Promise<{ symbol: string }> }): Promise<Metadata> {27  const g = await getGeneBySymbol((await params).symbol);28  return g ? { title: `${g.symbol} — gene`, description: `${g.symbol}${g.name ? ` (${g.name})` : ''}: curated cancer evidence, variants and cohort alteration frequencies.`, alternates: { canonical: `/gene/${g.symbol}` } } : { title: 'Gene' };29}3031export default async function GenePage({ params, searchParams }: { params: Promise<{ symbol: string }>; searchParams: Promise<SP> }) {32  const { symbol } = await params;33  const g = await getGeneBySymbol(symbol);34  if (!g) notFound();35  if (g.symbol !== symbol) permanentRedirect(`/gene/${g.symbol}`);36  const sp = await searchParams;37  const evPageReq = int(sp, 'evPage', 1, 1, 100_000);38  const vPageReq = int(sp, 'vPage', 1, 1, 100_000);39  const cPageReq = int(sp, 'cPage', 1, 1, 100_000);40  const pPageReq = int(sp, 'pPage', 1, 1, 100_000);4142  const [evTotal, vTotal, pTotal] = await Promise.all([evidenceForGeneCount(g.id, g.symbol), variantsForGeneCount(g.id), recentPublicationsForCount('gene', [g.id])]);43  const ev = pageInfo(evPageReq, EVIDENCE_PAGE_SIZE, evTotal);44  const vp = pageInfo(vPageReq, VARIANT_PAGE_SIZE, vTotal);45  const pp = pageInfo(pPageReq, PUBLICATION_PAGE_SIZE, pTotal);46  const [variants, evidence, freqs, pubs, cancersInEvidence] = await Promise.all([47    vTotal ? variantsForGene(g.id, { page: vp.page, pageSize: vp.pageSize }) : Promise.resolve([]),48    evTotal ? evidenceForGene(g.id, g.symbol, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]),49    frequenciesForGene(g.id, g.symbol),50    pTotal ? recentPublicationsFor('gene', [g.id], { page: pp.page, pageSize: pp.pageSize }) : Promise.resolve([]),51    evTotal ? evidenceCancersForGene(g.id, g.symbol, CANCER_CHIPS) : Promise.resolve([]),52  ]);53  const prov = await loadProvenance([...evidence.map((e) => e.provenance_id), ...freqs.map((f) => f.provenance_id)]);5455  const current = { evPage: ev.page > 1 ? ev.page : '', vPage: vp.page > 1 ? vp.page : '', pPage: pp.page > 1 ? pp.page : '', cPage: cPageReq > 1 ? cPageReq : '' };56  const href = (o: Record<string, string | number | null | undefined>, hash: string) => `/gene/${g.symbol}${withParams(current, o)}#${hash}`;5758  return (59    <article>60      <PageHeader kicker="Gene" title={<span className="ci-mono font-sans">{g.symbol}</span>} lede={g.name ?? undefined}>61        <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">62          <GraphLink type="gene" entityRef={g.symbol} />63          <span className="ci-mono text-ink-3">{g.id}</span>64          {g.hgnc_id ? (65            <a className="ci-link inline-flex items-center gap-1" href={`https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/${g.hgnc_id}`} target="_blank" rel="noopener noreferrer">66              {g.hgnc_id} <ExternalLink className="h-3 w-3" aria-hidden />67            </a>68          ) : null}69          {g.ensembl_gene_id ? (70            <a className="ci-link inline-flex items-center gap-1" href={`https://www.ensembl.org/Homo_sapiens/Gene/Summary?g=${g.ensembl_gene_id}`} target="_blank" rel="noopener noreferrer">71              {g.ensembl_gene_id} <ExternalLink className="h-3 w-3" aria-hidden />72            </a>73          ) : null}74          {g.ncbi_gene_id ? (75            <a className="ci-link inline-flex items-center gap-1" href={`https://www.ncbi.nlm.nih.gov/gene/${g.ncbi_gene_id}`} target="_blank" rel="noopener noreferrer">76              NCBI {g.ncbi_gene_id} <ExternalLink className="h-3 w-3" aria-hidden />77            </a>78          ) : null}79          {g.civic_gene_id ? (80            <a className="ci-link inline-flex items-center gap-1" href={`https://civicdb.org/genes/${g.civic_gene_id}/summary`} target="_blank" rel="noopener noreferrer">81              CIViC <ExternalLink className="h-3 w-3" aria-hidden />82            </a>83          ) : null}84          {g.is_cancer_gene ? <Badge tone="accent" title="Has at least one curated cancer edge (derived flag)">Cancer gene</Badge> : null}85          <Badge tone="outline">{g.status}</Badge>86        </p>87      </PageHeader>8889      <div className="grid gap-8 lg:grid-cols-[1fr_320px]">90        <div className="space-y-8">91          {g.description ? (92            <Section id="summary" kicker="Summary" title="Description">93              <p className="max-w-3xl text-[15px] leading-relaxed">{g.description}</p>94            </Section>95          ) : null}9697          <Section id="evidence" kicker="Curated evidence" title={`Clinical evidence (${fmtInt(evTotal)})`} description={`CIViC items involving this gene, grouped by molecular profile and therapy, with native levels and directions. ${EVIDENCE_PAGE_SIZE} items per page.`}>98            {evTotal ? (99              <>100                {cancersInEvidence.length ? (101                  <nav aria-label="Cancers in evidence" className="mb-3">102                    <ul className="m-0 flex list-none flex-wrap gap-1.5 p-0 text-[12.5px]">103                      <li className="ci-kicker mr-1 self-center">{cancersInEvidence.length >= CANCER_CHIPS ? `Top ${CANCER_CHIPS} cancers` : 'Cancers'}</li>104                      {cancersInEvidence.map((c) => (105                        <li key={c.slug}>106                          <Link href={`/cancer/${c.slug}/evidence`} className="ci-chip">107                            {c.name} <span className="ci-num text-ink-3">{c.n}</span>108                          </Link>109                        </li>110                      ))}111                    </ul>112                  </nav>113                ) : null}114                <EvidenceTable115                  items={evidence}116                  prov={prov}117                  showCancer118                  summary={119                    <>120                      Showing {fmtInt(ev.from)}–{fmtInt(ev.to)} of {fmtInt(evTotal)} evidence items121                    </>122                  }123                />124                <Pager total={evTotal} pageSize={ev.pageSize} page={ev.page} hrefFor={(p) => href({ evPage: p > 1 ? p : '' }, 'evidence')} label="Evidence pages" noun="evidence items" />125              </>126            ) : (127              <EmptyState compact>No curated evidence item involves this gene yet.</EmptyState>128            )}129          </Section>130131          <Section id="frequencies" kicker="Cohorts" title={`Alteration frequency by cohort (${fmtInt(freqs.length)})`} description="Frequency = cases affected / cases profiled within one cohort. Cohorts are never pooled.">132            {freqs.length ? <GeneFrequencyTable rows={freqs} prov={prov} page={cPageReq} hrefFor={(p) => href({ cPage: p > 1 ? p : '' }, 'frequencies')} /> : <EmptyState compact>No cohort frequency recorded for this gene.</EmptyState>}133          </Section>134135          <Section id="publications" kicker="Literature" title={`Linked publications (${fmtInt(pTotal)})`} description={pTotal ? `${PUBLICATION_PAGE_SIZE} per page, newest first.` : undefined}>136            {pubs.length ? (137              <>138                <PublicationList139                  rows={pubs}140                  summary={141                    <>142                      Showing {fmtInt(pp.from)}–{fmtInt(pp.to)} of {fmtInt(pTotal)} publications143                    </>144                  }145                />146                <Pager total={pTotal} pageSize={pp.pageSize} page={pp.page} hrefFor={(p) => href({ pPage: p > 1 ? p : '' }, 'publications')} label="Publication pages" noun="publications" />147              </>148            ) : (149              <EmptyState compact>No publication linked to this gene yet.</EmptyState>150            )}151          </Section>152        </div>153154        <aside className="space-y-8">155          <Section id="identity" kicker="HGNC" title="Record" level={3}>156            <KV157              items={[158                { k: 'Location', v: g.location ? <span className="ci-mono">{g.location}</span> : null },159                { k: 'Chromosome', v: g.chromosome },160                { k: 'Locus type', v: g.locus_type },161                { k: 'Locus group', v: g.locus_group },162                { k: 'Previous symbols', v: g.prev_symbols.length ? g.prev_symbols.join(', ') : null },163                { k: 'Alias symbols', v: g.alias_symbols.length ? g.alias_symbols.join(', ') : null },164                { k: 'Gene families', v: g.gene_families.length ? g.gene_families.join('; ') : null },165                { k: 'UniProt', v: g.uniprot_ids.length ? g.uniprot_ids.join(', ') : null },166                { k: 'OMIM', v: g.omim_ids.length ? g.omim_ids.join(', ') : null },167                { k: 'RefSeq', v: g.refseq_accession },168              ]}169            />170            <Freshness dataUpdatedAt={g.updated_at} extra="source: hgnc" />171          </Section>172          <Section id="variants" kicker="Variants" title={`Variants (${fmtInt(vTotal)})`} description={vTotal > VARIANT_PAGE_SIZE ? `Sorted by evidence count, ${VARIANT_PAGE_SIZE} per page.` : undefined} level={3}>173            {variants.length ? (174              <>175                <ul className="ci-rows">176                  {variants.map((v) => (177                    <li key={v.id}>178                      <Link className="ci-link" href={`/variant/${v.slug}`}>179                        {v.name}180                      </Link>181                      <span>182                        {v.variant_type ? humanize(v.variant_type) : ''}183                        {v.evidence_count ? ` · ${v.evidence_count} ev.` : ''}184                      </span>185                    </li>186                  ))}187                </ul>188                <Pager total={vTotal} pageSize={vp.pageSize} page={vp.page} hrefFor={(p) => href({ vPage: p > 1 ? p : '' }, 'variants')} label="Variant pages" noun="variants" className="text-[12px]" />189              </>190            ) : (191              <p className="text-[13px] text-ink-3">No variant entity recorded for this gene.</p>192            )}193          </Section>194          <Note>Gene-level pages aggregate curated evidence across cancers; the cancer context of each item is shown in the table and must not be generalized.</Note>195        </aside>196      </div>197    </article>198  );199}200